Using the VidigiPriorityStore to Simplify Model Code Changes with Priority Resources

In vidigi 0.0.5, a new version of VidigiPriorityStore has been added that can be used almost exactly like a resource - reducing the amount of rewriting required to incorporate vidigi into your model.

This allows us to use the pattern

with self.treatment_cubicles.request(priority=patient.priority) as req:

    treatment_resource = yield req

    ### Continue all code in the indented portion that requires the resource, with the resource
    ### automatically being returned to the store when the indented portion completes

Instead of

treatment_resource = yield self.treatment_cubicles.get(priority=patient.priority)

### Continue all code that requires the resource

self.treatment_cubicles.put(treatment_resource)

(even though we are still using a Store behind the scenes)

This minimizes the syntax changes and rewriting that are required when converting an existing model built using resources to a vidigi-compatible state.

When setting up the resources, we simply use the pattern

from vidigi.utils import VidigiPriorityStore, populate_store

...

self.treatment_cubicles = VidigiPriorityStore(self.env)

populate_store(num_resources=g.n_cubicles, # or wherever you are storing your resource counts
               simpy_store=self.treatment_cubicles, # pass in the VidgiStore we created
               sim_env=self.env # include the simpy env this will sit in
               )
from examples.example_7_simplest_case_priority_resource_storewrapper.ex_7_model_classes import Trial, g
from vidigi.prep import reshape_for_animations, generate_animation_df
from vidigi.animation import generate_animation, animate_activity_log
import pandas as pd
import plotly.io as pio
pio.renderers.default = "notebook"
import os
import random
import numpy as np
import pandas as pd
import simpy
from sim_tools.distributions import Exponential, Lognormal
from vidigi.resources import VidigiPriorityStore

class g:
    '''
    Create a scenario to parameterise the simulation model

    Parameters:
    -----------
    random_number_set: int, optional (default=DEFAULT_RNG_SET)
        Set to control the initial seeds of each stream of pseudo
        random numbers used in the model.

    n_cubicles: int
        The number of treatment cubicles

    trauma_treat_mean: float
        Mean of the trauma cubicle treatment distribution (Lognormal)

    trauma_treat_var: float
        Variance of the trauma cubicle treatment distribution (Lognormal)

        arrival_rate: float
        Set the mean of the exponential distribution that is used to sample the
        inter-arrival time of patients

    '''
    random_number_set = 42

    n_cubicles = 4
    trauma_treat_mean = 40
    trauma_treat_var = 5

    arrival_rate = 5
    sim_duration = 600
    number_of_runs = 100

class Patient:
    '''
    Class defining details for a patient entity
    '''
    def __init__(self, p_id):
        '''
        Constructor method

        Params:
        -----
        identifier: int
            a numeric identifier for the patient.
        '''
        self.identifier = p_id
        self.arrival = -np.inf
        self.wait_treat = -np.inf
        self.total_time = -np.inf
        self.treat_duration = -np.inf

        # Randomly initialise a patient priority value
        # Lower values will be prioritised - so priority 1 will be seen before priority 2
        if random.uniform(0, 1) < 0.2:
            self.priority = 1
        else:
            self.priority = 2

class Model:
    '''
    Simulates the simplest minor treatment process for a patient

    1. Arrive
    2. Examined/treated by nurse when one available
    3. Discharged
    '''
    # Constructor to set up the model for a run.  We pass in a run number when
    # we create a new model.
    def __init__(self, run_number):
        # Create a SimPy environment in which everything will live
        self.env = simpy.Environment()

        self.event_log = []

        # Create a patient counter (which we'll use as a patient ID)
        self.patient_counter = 0

        self.patients = []

        # Create our resources
        self.init_resources()

        # Store the passed in run number
        self.run_number = run_number

        # Create a new Pandas DataFrame that will store some results against
        # the patient ID (which we'll use as the index).
        self.results_df = pd.DataFrame()
        self.results_df["Patient ID"] = [1]
        self.results_df["Queue Time Cubicle"] = [0.0]
        self.results_df["Time with Nurse"] = [0.0]
        self.results_df.set_index("Patient ID", inplace=True)

        # Create an attribute to store the mean queuing times across this run of
        # the model
        self.mean_q_time_cubicle = 0

        self.patient_inter_arrival_dist = Exponential(mean = g.arrival_rate,
                                                      random_seed = self.run_number*g.random_number_set)
        self.treat_dist = Lognormal(mean = g.trauma_treat_mean,
                                    stdev = g.trauma_treat_var,
                                    random_seed = self.run_number*g.random_number_set)

    def init_resources(self):
        '''
        Init the number of resources
        and store in the arguments container object

        Resource list:
            1. Nurses/treatment bays (same thing in this model)

        '''
        self.treatment_cubicles = VidigiPriorityStore(self.env, num_resources=g.n_cubicles)

    # A generator function that represents the DES generator for patient arrivals
    def generator_patient_arrivals(self):
        # Use an infinite loop here to keep doing this indefinitely while the simulation runs
        while True:
            # Increment the patient counter by 1 (first patient will have an ID of 1)
            self.patient_counter += 1

            p = Patient(self.patient_counter)

            # Store patient in list for later easy access
            self.patients.append(p)

            # Tell SimPy to start up the attend_clinic generator function with this patient
            # (the generator function that will model the patient's journey through the system)
            self.env.process(self.attend_clinic(p))

            # Randomly sample the time to the next patient arriving
            sampled_inter = self.patient_inter_arrival_dist.sample()

            # Freeze this instance of this function in place until the inter-arrival time
            # sampled above has elapsed
            yield self.env.timeout(sampled_inter)

    def attend_clinic(self, patient):
        """
        A generator function that represents the pathway for a patient going through the clinic.

        The patient object is passed in to the generator function so we can extract information
        from / record information to it
        """
        self.arrival = self.env.now

        # ===== LOGGING FOR VIDIGI ANIMATION  ===== #
        self.event_log.append(
            {'patient': patient.identifier,
             'pathway': patient.priority,
             'event_type': 'arrival_departure',
             'event': 'arrival',
             'time': self.env.now}
        )
        # ========================================= #

        # request examination resource
        start_wait = self.env.now

        # ===== LOGGING FOR VIDIGI ANIMATION  ===== #
        self.event_log.append(
            {'patient': patient.identifier,
             'pathway': patient.priority,
             'event': 'treatment_wait_begins',
             'event_type': 'queue',
             'time': self.env.now}
        )
        # ========================================= #

        # Seize a treatment resource when available
        # Note that we must pass in the patient priority
        with self.treatment_cubicles.request(priority=patient.priority) as req:

            treatment_resource = yield req

            # record the waiting time for registration
            self.wait_treat = self.env.now - start_wait

            # ===== LOGGING FOR VIDIGI ANIMATION  ===== #
            self.event_log.append(
                {'patient': patient.identifier,
                    'pathway': patient.priority,
                    'event': 'treatment_begins',
                    'event_type': 'resource_use',
                    'time': self.env.now,
                    'resource_id': treatment_resource.id_attribute
                    }
            )
            # ========================================= #

            # sample treatment duration
            self.treat_duration = self.treat_dist.sample()
            yield self.env.timeout(self.treat_duration)

            # ===== LOGGING FOR VIDIGI ANIMATION  ===== #
            self.event_log.append(
                {'patient': patient.identifier,
                    'pathway': patient.priority,
                    'event': 'treatment_complete',
                    'event_type': 'resource_use_end',
                    'time': self.env.now,
                    'resource_id': treatment_resource.id_attribute}
            )
            # ========================================= #


        # total time in system
        self.total_time = self.env.now - self.arrival

        # ===== LOGGING FOR VIDIGI ANIMATION  ===== #
        self.event_log.append(
            {'patient': patient.identifier,
            'pathway': patient.priority,
            'event': 'depart',
            'event_type': 'arrival_departure',
            'time': self.env.now}
        )
        # ========================================= #


    # This method calculates results over a single run.  Here we just calculate
    # a mean, but in real world models you'd probably want to calculate more.
    def calculate_run_results(self):
        # Take the mean of the queuing times across patients in this run of the
        # model.
        self.mean_q_time_cubicle = self.results_df["Queue Time Cubicle"].mean()

    # The run method starts up the DES entity generators, runs the simulation,
    # and in turns calls anything we need to generate results for the run
    def run(self):
        # Start up our DES entity generators that create new patients.  We've
        # only got one in this model, but we'd need to do this for each one if
        # we had multiple generators.
        self.env.process(self.generator_patient_arrivals())

        # Run the model for the duration specified in g class
        self.env.run(until=g.sim_duration)

        # Now the simulation run has finished, call the method that calculates
        # run results
        self.calculate_run_results()

        self.event_log = pd.DataFrame(self.event_log)

        self.event_log["run"] = self.run_number

        return {'results': self.results_df, 'event_log': self.event_log}

# Class representing a Trial for our simulation - a batch of simulation runs.
class Trial:
    # The constructor sets up a pandas dataframe that will store the key
    # results from each run against run number, with run number as the index.
    def  __init__(self):
        self.df_trial_results = pd.DataFrame()
        self.df_trial_results["Run Number"] = [0]
        self.df_trial_results["Arrivals"] = [0]
        self.df_trial_results["Mean Queue Time Cubicle"] = [0.0]
        self.df_trial_results.set_index("Run Number", inplace=True)

        self.all_event_logs = []

    # Method to run a trial
    def run_trial(self):
        print(f"{g.n_cubicles} nurses")
        print("") ## Print a blank line

        # Run the simulation for the number of runs specified in g class.
        # For each run, we create a new instance of the Model class and call its
        # run method, which sets everything else in motion.  Once the run has
        # completed, we grab out the stored run results (just mean queuing time
        # here) and store it against the run number in the trial results
        # dataframe.
        for run in range(g.number_of_runs):
            random.seed(run)

            my_model = Model(run)
            model_outputs = my_model.run()
            patient_level_results = model_outputs["results"]
            event_log = model_outputs["event_log"]

            self.df_trial_results.loc[run] = [
                len(patient_level_results),
                my_model.mean_q_time_cubicle,
            ]

            # print(event_log)

            self.all_event_logs.append(event_log)

        self.all_event_logs = pd.concat(self.all_event_logs)
my_trial = Trial()

my_trial.run_trial()
4 nurses
my_trial.all_event_logs.head(50)
patient pathway event_type event time resource_id run
0 1 2 arrival_departure arrival 0.000000 NaN 0
1 1 2 queue treatment_wait_begins 0.000000 NaN 0
2 1 2 resource_use treatment_begins 0.000000 1.0 0
3 2 2 arrival_departure arrival 3.399660 NaN 0
4 2 2 queue treatment_wait_begins 3.399660 NaN 0
5 2 2 resource_use treatment_begins 3.399660 2.0 0
6 3 2 arrival_departure arrival 8.497645 NaN 0
7 3 2 queue treatment_wait_begins 8.497645 NaN 0
8 3 2 resource_use treatment_begins 8.497645 3.0 0
9 4 2 arrival_departure arrival 8.596678 NaN 0
10 4 2 queue treatment_wait_begins 8.596678 NaN 0
11 4 2 resource_use treatment_begins 8.596678 4.0 0
12 5 2 arrival_departure arrival 8.608025 NaN 0
13 5 2 queue treatment_wait_begins 8.608025 NaN 0
14 6 2 arrival_departure arrival 11.359739 NaN 0
15 6 2 queue treatment_wait_begins 11.359739 NaN 0
16 7 2 arrival_departure arrival 19.509442 NaN 0
17 7 2 queue treatment_wait_begins 19.509442 NaN 0
18 8 2 arrival_departure arrival 22.877356 NaN 0
19 8 2 queue treatment_wait_begins 22.877356 NaN 0
20 9 2 arrival_departure arrival 26.653863 NaN 0
21 9 2 queue treatment_wait_begins 26.653863 NaN 0
22 1 2 resource_use_end treatment_complete 40.317385 1.0 0
23 1 2 arrival_departure depart 40.317385 NaN 0
24 5 2 resource_use treatment_begins 40.317385 1.0 0
25 10 2 arrival_departure arrival 40.737793 NaN 0
26 10 2 queue treatment_wait_begins 40.737793 NaN 0
27 2 2 resource_use_end treatment_complete 42.443230 2.0 0
28 2 2 arrival_departure depart 42.443230 NaN 0
29 6 2 resource_use treatment_begins 42.443230 2.0 0
30 4 2 resource_use_end treatment_complete 48.809628 4.0 0
31 4 2 arrival_departure depart 48.809628 NaN 0
32 7 2 resource_use treatment_begins 48.809628 4.0 0
33 3 2 resource_use_end treatment_complete 51.483457 3.0 0
34 3 2 arrival_departure depart 51.483457 NaN 0
35 8 2 resource_use treatment_begins 51.483457 3.0 0
36 11 2 arrival_departure arrival 71.026558 NaN 0
37 11 2 queue treatment_wait_begins 71.026558 NaN 0
38 5 2 resource_use_end treatment_complete 77.447488 1.0 0
39 5 2 arrival_departure depart 77.447488 NaN 0
40 9 2 resource_use treatment_begins 77.447488 1.0 0
41 6 2 resource_use_end treatment_complete 83.962251 2.0 0
42 6 2 arrival_departure depart 83.962251 NaN 0
43 10 2 resource_use treatment_begins 83.962251 2.0 0
44 12 2 arrival_departure arrival 87.458700 NaN 0
45 12 2 queue treatment_wait_begins 87.458700 NaN 0
46 13 2 arrival_departure arrival 87.465138 NaN 0
47 13 2 queue treatment_wait_begins 87.465138 NaN 0
48 7 2 resource_use_end treatment_complete 95.498040 4.0 0
49 7 2 arrival_departure depart 95.498040 NaN 0
STEP_SNAPSHOT_MAX = 45
LIMIT_DURATION = g.sim_duration
WRAP_QUEUES_AT = 15
full_patient_df = reshape_for_animations(
    event_log=my_trial.all_event_logs[my_trial.all_event_logs['run']==1],
    every_x_time_units=2,
    entity_col_name="patient",
    step_snapshot_max=STEP_SNAPSHOT_MAX,
    limit_duration=LIMIT_DURATION,
    debug_mode=True
    )

full_patient_df.head(15)
Iteration through time-unit-by-time-unit logs complete 12:37:59
Snapshot df concatenation complete at 12:37:59
index patient pathway event_type event time resource_id run rank snapshot_time additional
0 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 0 NaN
1 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 2 NaN
2 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 4 NaN
3 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 6 NaN
4 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 8 NaN
5 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 10 NaN
6 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 12 NaN
7 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 14 NaN
8 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 16 NaN
9 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 18 NaN
10 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 20 NaN
11 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 22 NaN
12 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 24 NaN
13 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 26 NaN
14 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 28 NaN
event_position_df = pd.DataFrame([
                    {'event': 'arrival',
                     'x':  50, 'y': 300,
                     'label': "Arrival" },

                    # Triage - minor and trauma
                    {'event': 'treatment_wait_begins',
                     'x':  205, 'y': 275,
                     'label': "Waiting for Treatment"},

                    {'event': 'treatment_begins',
                     'x':  205, 'y': 175,
                     'resource':'n_cubicles',
                     'label': "Being Treated"},

                    {'event': 'exit',
                     'x':  270, 'y': 70,
                     'label': "Exit"}

                ])

Generate animation using the step-by-step functions

Using the three step-by-step functions allows us to intervene in the produced dataframe and manually take control of the icons in use.

This will allow us to show the high-priority patients with a unique icon so we can see their frequency and how they are handled in the final model.

full_patient_df_plus_pos = generate_animation_df(
    full_entity_df=full_patient_df,
    event_position_df=event_position_df,
    entity_col_name="patient",
    wrap_queues_at=WRAP_QUEUES_AT,
    step_snapshot_max=STEP_SNAPSHOT_MAX,
    gap_between_entities=10,
    gap_between_resources=10,
    gap_between_resource_rows=30,
    gap_between_queue_rows=30,
    debug_mode=True
    )

full_patient_df_plus_pos.sort_values(['patient', 'snapshot_time']).head(15)
Placement dataframe finished construction at 12:37:59
index patient pathway event_type event time resource_id run rank snapshot_time additional x y_final label resource x_final row icon
10186 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 0 NaN 205 175.0 Being Treated n_cubicles 205.0 0.0 🧔🏼
10187 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 2 NaN 205 175.0 Being Treated n_cubicles 205.0 0.0 🧔🏼
10188 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 4 NaN 205 175.0 Being Treated n_cubicles 205.0 0.0 🧔🏼
10189 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 6 NaN 205 175.0 Being Treated n_cubicles 205.0 0.0 🧔🏼
10190 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 8 NaN 205 175.0 Being Treated n_cubicles 205.0 0.0 🧔🏼
10191 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 10 NaN 205 175.0 Being Treated n_cubicles 205.0 0.0 🧔🏼
10192 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 12 NaN 205 175.0 Being Treated n_cubicles 205.0 0.0 🧔🏼
10193 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 14 NaN 205 175.0 Being Treated n_cubicles 205.0 0.0 🧔🏼
10194 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 16 NaN 205 175.0 Being Treated n_cubicles 205.0 0.0 🧔🏼
10195 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 18 NaN 205 175.0 Being Treated n_cubicles 205.0 0.0 🧔🏼
10196 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 20 NaN 205 175.0 Being Treated n_cubicles 205.0 0.0 🧔🏼
10197 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 22 NaN 205 175.0 Being Treated n_cubicles 205.0 0.0 🧔🏼
10198 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 24 NaN 205 175.0 Being Treated n_cubicles 205.0 0.0 🧔🏼
10199 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 26 NaN 205 175.0 Being Treated n_cubicles 205.0 0.0 🧔🏼
10200 2 1 1 resource_use treatment_begins 0.0 1.0 1 1.0 28 NaN 205 175.0 Being Treated n_cubicles 205.0 0.0 🧔🏼
def show_priority_icon(row):
            if "more" not in row["icon"]:
                if row["pathway"] == 1:
                        return "🚨"
                else:
                    return row["icon"]
            else:
                return row["icon"]
full_patient_df_plus_pos = full_patient_df_plus_pos.assign(
            icon=full_patient_df_plus_pos.apply(show_priority_icon, axis=1)
            )
full_patient_df_plus_pos.head(15)
index patient pathway event_type event time resource_id run rank snapshot_time additional x y_final label resource x_final row icon
0 155 49 2 queue exit 208.540636 NaN 1 1.0 598 NaN 270 70.0 Exit NaN 270.0 0.0 🧕🏾
1 155 49 2 queue treatment_wait_begins 206.540636 NaN 1 27.0 208 NaN 205 305.0 Waiting for Treatment NaN 95.0 1.0 🧕🏾
2 155 49 2 queue treatment_wait_begins 206.540636 NaN 1 27.0 210 NaN 205 305.0 Waiting for Treatment NaN 95.0 1.0 🧕🏾
3 155 49 2 queue treatment_wait_begins 206.540636 NaN 1 27.0 212 NaN 205 305.0 Waiting for Treatment NaN 95.0 1.0 🧕🏾
4 155 49 2 queue treatment_wait_begins 206.540636 NaN 1 27.0 214 NaN 205 305.0 Waiting for Treatment NaN 95.0 1.0 🧕🏾
5 155 49 2 queue treatment_wait_begins 206.540636 NaN 1 27.0 216 NaN 205 305.0 Waiting for Treatment NaN 95.0 1.0 🧕🏾
6 155 49 2 queue treatment_wait_begins 206.540636 NaN 1 27.0 218 NaN 205 305.0 Waiting for Treatment NaN 95.0 1.0 🧕🏾
7 155 49 2 queue treatment_wait_begins 206.540636 NaN 1 27.0 220 NaN 205 305.0 Waiting for Treatment NaN 95.0 1.0 🧕🏾
8 155 49 2 queue treatment_wait_begins 206.540636 NaN 1 27.0 222 NaN 205 305.0 Waiting for Treatment NaN 95.0 1.0 🧕🏾
9 155 49 2 queue treatment_wait_begins 206.540636 NaN 1 27.0 224 NaN 205 305.0 Waiting for Treatment NaN 95.0 1.0 🧕🏾
10 155 49 2 queue treatment_wait_begins 206.540636 NaN 1 27.0 226 NaN 205 305.0 Waiting for Treatment NaN 95.0 1.0 🧕🏾
11 155 49 2 queue treatment_wait_begins 206.540636 NaN 1 27.0 228 NaN 205 305.0 Waiting for Treatment NaN 95.0 1.0 🧕🏾
12 155 49 2 queue treatment_wait_begins 206.540636 NaN 1 27.0 230 NaN 205 305.0 Waiting for Treatment NaN 95.0 1.0 🧕🏾
13 155 49 2 queue treatment_wait_begins 206.540636 NaN 1 27.0 232 NaN 205 305.0 Waiting for Treatment NaN 95.0 1.0 🧕🏾
14 155 49 2 queue treatment_wait_begins 206.540636 NaN 1 26.0 234 NaN 205 305.0 Waiting for Treatment NaN 105.0 1.0 🧕🏾
generate_animation(
        full_entity_df_plus_pos=full_patient_df_plus_pos.sort_values(['patient', 'snapshot_time']),
        event_position_df= event_position_df,
        scenario=g(),
        entity_col_name="patient",
        debug_mode=True,
        setup_mode=False,
        include_play_button=True,
        entity_icon_size=20,
        resource_icon_size=20,
        gap_between_resource_rows=30,
        plotly_height=700,
        frame_duration=800,
        frame_transition_duration=200,
        plotly_width=1200,
        override_x_max=300,
        override_y_max=500,
        time_display_units="dhm",
        display_stage_labels=False,
        add_background_image="https://raw.githubusercontent.com/Bergam0t/vidigi/refs/heads/main/examples/example_1_simplest_case/Simplest%20Model%20Background%20Image%20-%20Horizontal%20Layout.drawio.png",
    )
Output animation generation complete at 12:38:11

Rerun, but using the all-in-one animation function (which will not show different priority icons)

animate_activity_log(
        event_log=my_trial.all_event_logs[my_trial.all_event_logs['run']==1],
        event_position_df= event_position_df,
        scenario=g(),
        entity_col_name="patient",
        debug_mode=True,
        setup_mode=False,
        every_x_time_units=1,
        include_play_button=True,
        entity_icon_size=20,
        resource_icon_size=20,
        gap_between_entities=6,
        gap_between_queue_rows=25,
        gap_between_resource_rows=25,
        plotly_height=700,
        frame_duration=200,
        plotly_width=1200,
        override_x_max=300,
        override_y_max=500,
        limit_duration=g.sim_duration,
        wrap_queues_at=25,
        step_snapshot_max=125,
        time_display_units="dhm",
        display_stage_labels=False,
        add_background_image="https://raw.githubusercontent.com/Bergam0t/vidigi/refs/heads/main/examples/example_1_simplest_case/Simplest%20Model%20Background%20Image%20-%20Horizontal%20Layout.drawio.png",
    )
Animation function called at 12:39:05
Iteration through time-unit-by-time-unit logs complete 12:39:08
Snapshot df concatenation complete at 12:39:08
Reshaped animation dataframe finished construction at 12:39:08
Placement dataframe finished construction at 12:39:08
Output animation generation complete at 12:39:11
Total Time Elapsed: 5.72 seconds